
from dotfilebackup.parser import (
    parse_s, parse_f, ParsingError, KeyInterpolationHandler, InterpolationError
)

from unittest.mock import patch, mock_open, Mock
from pytest import mark, raises
import logging


class TestIgnored:
    def test_empty_line(self):
        raw = ('key: 1\n'
               '\n'
               'list:\n'
               '  - foo\n'
               '       \n'
               '  - bar\n'
               '  \n'
               '\n'
               '  - baz')
        exp = {'key': '1', 'list': ['foo', 'bar', 'baz']}
        assert parse_s(raw) == exp

    def test_comments_entire_line(self):
        raw = ('key: 1\n'
               '# list section\n'
               'list:\n'
               '  - foo\n'
               '#\n'
               '# - foo_1\n'
               '# - foo_2\n'
               '#\n'
               '  - bar\n'
               '  - baz')
        exp = {'key': '1', 'list': ['foo', 'bar', 'baz']}
        assert parse_s(raw) == exp

    def test_comments_inline(self):
        raw = ('key: 1   # beer I say only one \n'
               'list:    # it alwasy starts with 1 \n'
               '  - foo\n'
               '  - bar#close combat quarters inline comment \n'
               '  - baz         # after party inline comment')
        exp = {'key': '1', 'list': ['foo', 'bar', 'baz']}
        assert parse_s(raw) == exp


class TestIndentation:
    @mark.parametrize('RAW, LINE_NR', (
        (('  key:val'), 1),
        (('key_1: val\n'
          '  key_2; val'), 2),
        (('key_1: val\n'
          'key_2: val\n'
          '  key_3; val'), 3),
    ))
    def test_top_level_error(self, RAW, LINE_NR):
        with raises(ParsingError) as e: parse_s(RAW)
        assert f'line {LINE_NR}' in str(e.value)
        assert 'unexpected indentation' in str(e.value)
        # assert 'unexpected indentation' in str(e.value)

    @mark.parametrize('RAW, LINE_NR, ERR_MSG', (
        (('list:\n'
          '- foo\n'), 2, 'unindented list'),
        (('list:\n'
          '    - foo\n'
          '- bar\n'), 3, 'expected 4 spaces indentation'),
        (('list:\n'
          '   - foo\n'
          '  - bar\n'), 3, 'expected 3 spaces indentation'),
        (('list:\n'
          '  - foo\n'
          '    - bar\n'), 3, 'expected 2 spaces indentation'),
        (('list:\n'
          '\t- foo\n'), 2, 'tab in indentation'),
        (('list:\n'
          '    - foo\n'
          '\t- bar\n'), 3, 'tab in indentation'),
    ))
    def test_list_errors(self, RAW, LINE_NR, ERR_MSG):
        with raises(ParsingError) as e: parse_s(RAW)
        assert f'line {LINE_NR}' in str(e.value)
        assert ERR_MSG in str(e.value)


class TestStandAloneValues:
    def test_value(self):
        standalone_defaults = {
            'stand':   ('True', 'False'),  #key: (on_present, on_missing)
            'alone':   ['False', 'True'],
            'complex': ('ghost in the shell', 'ghost outside the shell')}
        raw = ('stand\n'
               'alone  ')
        exp = {'stand': 'True',
               'alone': 'False',
               'complex': 'ghost outside the shell'}
        assert parse_s(raw, standalone_keys=standalone_defaults) == exp

    def test_missing_error(self):
        raw = 'standalone'
        with raises(ParsingError) as e: parse_s(raw, standalone_keys={})
        assert f'line 1' in str(e.value)
        assert f'unknown standalone key "standalone"' in str(e.value)


class TestKeyVal:
    @mark.parametrize('RAW', ('key:-\n', 'key: -\n', 'key: -   \n'))
    def test_empty_value(self, RAW):
        assert parse_s(RAW) == {'key': ''}

    @mark.parametrize('RAW, LINE_NR', (
        ('key_1:', -1),
        ('key_1: ', -1),
        ('key_1:   ', -1),
        ('key_1:\n', -1),
        ('key_1: \n', -1),
        ('key_1:   \n', -1),
        ('key_1:\n'
         'key_2: foo', 1),
        ('key_1: \n'
         'key_2: foo', 1),
        ('key_1:    \n'
         'key_2: foo', 1),
        ('key_2: foo\n'
         'key_1:', -1),
        ('key_2: foo\n'
         'key_1: ', -1),
        ('key_2: foo\n'
         'key_1:    ', -1),
        ('key_3: foo\n'
         'key_2: bar\n'
         'key_1:      ', -1),
        ('key_3: foo\n'
         'key_2: bar\n'
         'key_1:    \n', -1),
    ))
    def test_empty_value_error(self, RAW, LINE_NR):
        with raises(ParsingError) as e: parse_s(RAW)
        assert f'line {LINE_NR}' in str(e.value)
        assert 'use "key_1: -" for empty value' in str(e.value)

    @mark.parametrize('RAW, EXP', (
        ('key: val_1\n',      {'key': 'val_1'}),
        ('key : val_2\n',     {'key': 'val_2'}),
        ('key  : val_3\n',    {'key': 'val_3'}),
        ('key     : val_4\n', {'key': 'val_4'}),
    ))
    def test_key_spaces_preservation(self, RAW, EXP):
        assert parse_s(RAW) == EXP

    @mark.parametrize('RAW, EXP', (
        ('key:val_1\n',       {'key': 'val_1'}),
        ('key: val_2\n',      {'key': 'val_2'}),
        ('key:  val_3 \n',    {'key': ' val_3'}),
        ('key:   val_4  \n',  {'key': '  val_4'}),
        ('key: val    5  \n', {'key': 'val    5'}),
    ))
    def test_value_spaces_preservation(self, RAW, EXP):
        assert parse_s(RAW) == EXP

    @mark.parametrize('KEY, ERR_MSG', (
        (':foo',      'empty key'),
        (': bar',     'empty key'),
        ('-foo',      'key begins with "-"'),
        ('-foo: bar', 'key begins with "-"'),
        ('f oo',      'key contains space, '),
        ('f\too',     'key contains tab, '),
        ('f{oo',      'key contains "{", '),
        ('f}oo',      'key contains "}", '),
        ('f{o\to',    'key contains "{", tab, '),
        ('f {o}\to',  'key contains space, "{", "}", tab, '),
    ))
    def test_key_errors(self, KEY, ERR_MSG):
        with raises(ParsingError) as e: parse_s(f'{KEY}: bar')
        assert f'line 1' in str(e.value)
        assert ERR_MSG in str(e.value)

    def test_duplicated_key_error(self):
        with raises(ParsingError) as e: parse_s('foo: bar\n'
                                                'foo: baz')
        assert f'line 2' in str(e.value)
        assert 'duplicated key "foo"' in str(e.value)


class TestList:
    def test_list(self):
        raw = ('list_1:\n'
               ' - foo\n'
               ' - bar\n'
               'list_2:\n'
               '   - foo\n'
               '   - bar\n'
               'list_3:\n'
               '      - foo\n'
               '      - bar')
        exp = {'list_1': ['foo', 'bar'],
               'list_2': ['foo', 'bar'],
               'list_3': ['foo', 'bar']}
        assert parse_s(raw) == exp

    def test_empty_value_error(self):
        raw = ('list_1:\n'
               '  - ')
        with raises(ParsingError) as e: parse_s(raw)
        assert 'line 2' in str(e.value)
        assert 'empty value in list' in str(e.value)

    def test_nesting_not_allowed(self):
        raw = ('list:\n'
               '  - foo\n'
               '  - bar_list:\n'  # ok value [... ,'bar_list:' , ...]
               '    - bar_1:')    # bad indentation
        with raises(ParsingError) as e: parse_s(raw)
        assert 'line 4' in str(e.value)
        assert 'expected 2 spaces indentation' in str(e.value)

    @mark.parametrize('RAW, EXP', (
        (('list:\n'
          '    -foo\n'),         {'list': ['foo']}),
        (('list:\n'
          '    - foo\n'),        {'list': ['foo']}),
        (('list:\n'
          '    -  foo\n'),       {'list': [' foo']}),
        (('list:\n'
          '    -    foo\n'),     {'list': ['   foo']}),
        (('list:\n'
          # '    - foo    1\n'),  {'list': ['foo    1']}),
          '    - foo    1  \n'), {'list': ['foo    1']}),
    ))
    def test_spaces_preservation(self, RAW, EXP):
        assert parse_s(RAW) == EXP

    def test_second_minus_is_valid_value(self):
        raw = ('list_1:\n'
               ' --\n'
               ' - -     \n'
               ' -  -\n'
               ' -     -    ')
        exp = {'list_1': ['-', '-', ' -', '    -']}
        assert parse_s(raw) == exp


class TestBaseInterpolations:
        @mark.parametrize('RAW, EXP', (
            ('key: {left_curly}',   {'key': '{'}),
            ('key: {l}',            {'key': '{'}),
            ('key: {right_curly}',  {'key': '}'}),
            ('key: {r}',            {'key': '}'}),
            ('key: {space}',        {'key': ' '}),
            ('key: {s}',            {'key': ' '}),
            ('key: {s}{s}',         {'key': '  '}),  # special case
            ('key: {colon}',        {'key': ':'}),
            ('key: {c}',            {'key': ':'}),
            ('key: {hash}',         {'key': '#'}),
            ('key: {h}',            {'key': '#'}),
            ('key: {hyphen}',       {'key': '-'}),
            ('key: {minus}',        {'key': '-'}),
            ('key: {m}',            {'key': '-'}),
        ))
        def test_values(self, RAW, EXP):
            assert parse_s(RAW) == EXP

        def test_multiple_in_value(self):
            raw = ('key-1: {m}{m}color{c} {h}FFFFFFFFF\n'
                   'key-2: {l}name{r}{s}{h}interpolation')
            exp = {'key-1': '--color: #FFFFFFFFF',
                   'key-2': '{name} #interpolation'}
            assert parse_s(raw) == exp

        def test_multiple_in_list(self):
            raw = ('foo:\n'
                   '   - {m}{m}color{c} {h}FFFFFFFFF\n'
                   '   - {l}name{r}{s}{h}interpolation')
            exp = {'foo': ['--color: #FFFFFFFFF',
                           '{name} #interpolation']}
            assert parse_s(raw) == exp

        @mark.parametrize('RAW', (
            'key: }', 'key: }', 'key: {s}}', 'key: }{s}', 'key: {h}space}bar'
        ))
        def test_not_openend_interpolation(self, RAW):
            with raises(ParsingError) as e: parse_s(RAW)
            assert 'line 1' in str(e.value)
            assert 'not opened interpolation' in str(e.value)

        @mark.parametrize('RAW', (
            'key: {{}', 'key: {{space}', 'key: {space{}', 'key: {spa{ce}',
        ))
        def test_interpolation_contains_left_curly_err(self, RAW):
            with raises(ParsingError) as e: parse_s(RAW)
            assert 'line 1' in str(e.value)
            assert 'interpolation contains "{"' in str(e.value)

        def test_unknown_interpolation(self):
            with raises(ParsingError) as e: parse_s('key: {foobar}')
            assert 'line 1' in str(e.value)
            assert 'unknown interpolation "foobar"' in str(e.value)

        @mark.parametrize('NAME, VALUE', [
                ('left_curly',  '{'),
                ('l',           '{'),
                ('right_curly', '}'),
                ('r',           '}'),
                ('colon',       ':'),
                ('c',           ':'),
                ('hash',        '#'),
                ('h',           '#'),
                ('space',       ' '),
                ('s',           ' '),
                ('hyphen',      '-'),
                ('minus',       '-'),
                ('m',           '-'),
        ])
        def test_base_interpolations_cannot_be_changed(self, NAME, VALUE):
            raw = (f'{NAME}: foo\n'
                   f'key: {{{NAME}}}')
            exp = {f'{NAME}': 'foo',
                    'key': f'{VALUE}'}
            assert parse_s(raw, ) == exp


class TestAdditionalInterpolations:
    foo = {
        'name'       : 'foo',
        'parser_key' : True,
        'updatable'  : True,
        'val'        : 'foo',
        'format'     : None,
        'ignore'     : None,
        'forbidden'  : None,
    }
    bar = {
        'name'       : 'bar',
        'parser_key' : True,
        'updatable'  : False,
        'val'        : 'bar',
        'format'     : None,
        'ignore'     : None,
        'forbidden'  : None,
    }
    baz = {
        'name'       : 'baz',
        'parser_key' : False,
        'updatable'  : False,
        'val'        : 'baz',
        'format'     : None,
        'ignore'     : None,
        'forbidden'  : None,
    }
    foobar = {
        'name'       : 'foobar',
        'parser_key' : True,
        'updatable'  : True,
        'val'        : None,
        'format'     : '{foo}{bar}',
        'ignore'     : None,
        'forbidden'  : None,
    }

    def test_add_handler(self):
        ih = KeyInterpolationHandler([self.foo])
        raw = 'key: {foo}'
        exp = {'key': 'foo'}
        assert parse_s(raw, interpolations=ih) == exp

    def test_set_only_interpolations_with_parser_key(self):
        ih = KeyInterpolationHandler([self.foo, self.baz])
        assert ih.get('baz')['val'] == 'baz'
        raw = ('baz: BAZ')
        exp = {'baz': 'BAZ'}
        assert parse_s(raw, interpolations=ih) == exp
        assert ih.get('baz')['val'] == 'baz'

    def test_set_updatable(self):
        ih = KeyInterpolationHandler([self.foo])
        assert ih.get('foo')['val'] == 'foo'
        raw = 'foo: FOO'
        assert parse_s(raw, interpolations=ih) == {}
        assert ih.get('foo')['val'] == 'FOO'

    def test_set_non_updatable(self):
        ih = KeyInterpolationHandler([self.bar])
        raw = 'bar: FOO'
        with raises(InterpolationError) as e: parse_s(raw, interpolations=ih)
        assert "'bar' interpolation is non-updatable" in str(e.value)

    def test_set_sets_val_when_interpolation_has_only_val(self):
        ih = KeyInterpolationHandler([self.foo])
        assert ih.get('foo')['val'] == 'foo'
        raw = 'foo: FOO'
        assert parse_s(raw, interpolations=ih) == {}
        assert ih.get('foo')['val'] == 'FOO'

    def test_set_sets_format_when_interpolation_has_format(self):
        ih = KeyInterpolationHandler([self.foo, self.bar, self.foobar])
        assert ih.get('foobar')['format'] == '{foo}{bar}'
        raw = 'foobar: {bar}-{foo}'
        assert parse_s(raw, interpolations=ih) == {}
        assert ih.get('foobar')['format'] == 'bar-foo'  # 'foo', 'bar' not in ignore

    def test_ignores_true(self):
        no_foo_no_bar = {
                'name'       : 'no_foo_no_bar',
                'parser_key' : True,
                'updatable'  : True,
                'val'        : 'no_foo_no_bar',
                'format'     : None,
                'ignore'     : True,
                'forbidden'  : None,
        }
        ih = KeyInterpolationHandler([self.foo, self.bar, no_foo_no_bar])
        assert ih.get('no_foo_no_bar')['val'] == 'no_foo_no_bar'
        raw = 'no_foo_no_bar: no_{bar}_no_{foo}'
        assert parse_s(raw, interpolations=ih) == {}
        assert ih.get('no_foo_no_bar')['val'] == 'no_{bar}_no_{foo}'

    def test_ignores_items(self):
        no_foo_no_bar = {
                'name'       : 'no_foo_no_bar',
                'parser_key' : True,
                'updatable'  : True,
                'val'        : None,
                'format'     : 'no_foo_no_bar',
                'ignore'     : ['foo', 'bar'],
                'forbidden'  : None,
        }
        ih = KeyInterpolationHandler([self.foo, self.bar, self.baz, no_foo_no_bar])
        assert ih.get('no_foo_no_bar')['format'] == 'no_foo_no_bar'
        raw = 'no_foo_no_bar: no_{bar}_no_{foo}_but_{baz}'
        assert parse_s(raw, interpolations=ih) == {}
        assert ih.get('no_foo_no_bar')['format'] == 'no_{bar}_no_{foo}_but_baz'

    def test_base_interpolations_take_priority(self):
        class LeftCurlyDummyHandler:
            def __init__(self):
                self.parser_keys = {'left_curly': 'left_curly'}
                self.items = {'left_curly':  {
                        'name'       : 'left_curly',
                        'parser_key' : True,
                        'updatable'  : True,
                        'val'        : None,
                        'format'     : 'left_curly',
                        'ignore'     : None,
                        'forbidden'  : None,
                }}
            def set(self, item): pass
            def get(self, name): return self.items(name)

        ih = LeftCurlyDummyHandler()
        raw = ('key: {left_curly}')
        exp = {'key': '{'}
        assert parse_s(raw, interpolations=ih) == exp

    # @mark.skip('duplicate')
    # def test_parser_key_interpolations_not_included_in_returned_config(self)


class TestRaw:
    def test_value(self):
        raw = ('''r'foo: *my.jpg  # comment \n'''
               '''r'bar: %03{*&}'"-[@#] ($%)F:o+{space}''')
        exp = {'foo': '''*my.jpg  # comment''',
               'bar': '''%03{*&}'"-[@#] ($%)F:o+{space}'''}
        assert parse_s(raw) == exp

    def test_list(self):
        raw = ('''r'foo: \n'''
               '''   - *my.jpg  # comment\n'''
               '''   - %03{*&}'"-[@#] ($%)F:o+{space}\n''')
        exp = {'foo': ['''*my.jpg  # comment''',
                       '''%03{*&}'"-[@#] ($%)F:o+{space}''']}
        assert parse_s(raw) == exp

    def test_comment_in_list_declaration_is_a_val(self):
        raw = ('''r'foo: #comment\n'''
               '''   - *my.jpg\n''')
        with raises(ParsingError) as e: parse_s(raw)
        assert 'line 2' in str(e.value)
        assert 'tip: in r\'keys: # comment is value' in str(e.value)

    def test_exit_mode_on_new_scope_key(self):
        raw = ('''r'foo: \n'''
               '''   - *my.jpg  # comment\n'''
               '''bar: {h}baz \n''')
        exp = {'foo': ['''*my.jpg  # comment'''],
               'bar': '#baz'}
        assert parse_s(raw) == exp

    def test_exit_mode_on_new_scope_key_with_line_break(self):
        raw = ('''r'foo: \n'''
               '''   - *my.jpg  # comment\n'''
               ''' \n'''
               '''bar: {h}baz \n''')
        exp = {'foo': ['''*my.jpg  # comment'''],
               'bar': '#baz'}
        assert parse_s(raw) == exp

    def test_exit_mode_on_new_scope_comment(self):
        raw = ('''r'foo: \n'''
               '''   - *my.jpg  # comment\n'''
               '''# comment \n'''
               '''bar: baz \n''')
        exp = {'foo': ['''*my.jpg  # comment'''],
               'bar': 'baz'}
        assert parse_s(raw) == exp

    def test_exit_mode_on_new_scope_comment_with_line_break(self):
        raw = ('''r'foo: \n'''
               '''   - *my.jpg  # comment\n'''
               ''' \n'''
               '''# comment \n'''
               '''bar: baz \n''')
        exp = {'foo': ['''*my.jpg  # comment'''],
               'bar': 'baz'}
        assert parse_s(raw) == exp


class TestParseFile:
    @patch('builtins.open', new_callable=mock_open, read_data='foo: bar')
    def test_log_file_opening(self, m_open, caplog):
        dummy_path = '/foo/bar.conf'
        with caplog.at_level(logging.INFO):
            parse_f(dummy_path)
            assert f'opening {dummy_path}' in caplog.records[-1].msg

    @patch('builtins.open', new_callable=mock_open, read_data='foo:')
    def test_parsing_err(self, m_open, caplog):
        dummy_path = '/foo/bar.conf'
        with caplog.at_level(logging.INFO):
            with raises(ParsingError) as e: parse_f(dummy_path)
            assert dummy_path in caplog.records[-1].msg
            assert 'use "foo: -" for empty value' in caplog.records[-1].msg

    @patch('builtins.open', new_callable=mock_open)
    def test_oserror(self, m_open):
        m_open.side_effect = OSError('bar')
        with raises(OSError) as e: parse_f('/dummy/path')
        assert 'bar' in str(e.value)

    @patch('builtins.open', new_callable=mock_open, read_data='foo: bar')
    def test_open_file_mode_encoding(self, m_open):
        dummy_path = '/foo/bar.conf'
        parse_f(dummy_path)
        m_open.assert_called_once_with(dummy_path, 'r', encoding='utf-8')

    @patch('builtins.open', new_callable=mock_open, read_data='')
    def test_empty_file(self, m_open):
        assert parse_f('/dummy/path') == {}

    @mark.feature
    def test_parse_conf_file(self):
        conf = 'tests/parser_example.conf'
        exp = {
            'stand': True,
            'alone': 'no',
            'complex': 0,
            'key_0': '',
            'key_1': 'val',
            'key_2': 'val',
            'key_3': ' val',
            'list_1': [
                '-',
                'val-1 -',
                '-- val-2',
                'val-3---',
                ' val-4',
            ],
            'list_2': [
                'val-1',
                'use {name} for interpolations',
                'white: #FFFFFF',
                'black: #000000',
            ],
            'raw_list': [
                'exact name can be specified as raw before parsing on parser side',
                'in such case everything except first \'{indentation}- \' or "{indentation}-"',
                'will be taken as value until end of line is reached',
                '%03{*&}\'"-[@#]($%)F:o+{space}  # this is part of the value',
            ],
        }
        alones = {'stand': (True, False), 'alone': ('no', 'yes'), 'complex': (1, 0)}
        res = parse_f(conf, standalone_keys=alones)
        assert res == exp

#
